Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 4e4ef0526a424b653e48f5720d45765fdd6a4869


Parents : b5f9665
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-08-01T17:47:19-05:00

feat: add local backend status probing and port availability checks in meshchat_wrapper

Changes
Diff

diff --git a/android/app/src/main/python/meshchat_wrapper.py b/android/app/src/main/python/meshchat_wrapper.py
index 5833c40d..86ab9d44 100644
--- a/android/app/src/main/python/meshchat_wrapper.py
+++ b/android/app/src/main/python/meshchat_wrapper.py
@@ -1,9 +1,11 @@
# SPDX-License-Identifier: 0BSD
+import contextlib
import os
import signal
import sys
import threading
+import time
# Prevents a second meshchat main() if Java starts two threads (e.g. activity edge cases).
_server_loop_lock = threading.Lock()
@@ -134,6 +136,72 @@ def _install_android_rnode_support(activity=None):
print(f"meshchat_wrapper: Android RNode support skipped: {exc}")
+def _probe_local_status_payload(port, timeout=1.5):
+ import http.client
+ import json
+ import ssl
+
+ def _fetch(use_https):
+ conn = None
+ try:
+ if use_https:
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+ context.check_hostname = False
+ context.verify_mode = ssl.CERT_NONE
+ conn = http.client.HTTPSConnection(
+ "127.0.0.1",
+ port,
+ timeout=timeout,
+ context=context,
+ )
+ else:
+ conn = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout)
+ conn.request("GET", "/api/v1/status")
+ response = conn.getresponse()
+ body = response.read()
+ if response.status != 200:
+ return None
+ return json.loads(body.decode("utf-8"))
+ except Exception:
+ return None
+ finally:
+ if conn is not None:
+ with contextlib.suppress(Exception):
+ conn.close()
+
+ return _fetch(True) or _fetch(False)
+
+
+def _local_backend_matches(port, timeout=1.5):
+ payload = _probe_local_status_payload(port, timeout=timeout)
+ if not isinstance(payload, dict):
+ return False
+ expected_keys = {"stage", "network_ready", "listen_port", "https_enabled"}
+ if not expected_keys.issubset(payload.keys()):
+ return False
+ return payload.get("listen_port") == port
+
+
+def _wait_for_own_backend_or_free_port(
+ port,
+ *,
+ wait_seconds=3.0,
+ poll_interval=0.3,
+ probe_timeout=1.5,
+):
+ from meshchatx.src.backend.interface_port_check import is_port_in_use
+
+ deadline = time.monotonic() + wait_seconds
+ while True:
+ if not is_port_in_use("127.0.0.1", port):
+ return "free"
+ if _local_backend_matches(port, timeout=probe_timeout):
+ return "serving"
+ if time.monotonic() >= deadline:
+ return "busy"
+ time.sleep(poll_interval)
+
+
def start_server(port=8000, app_files_dir=None, activity=None):
global _server_loop_active
with _server_loop_lock:
@@ -153,6 +221,26 @@ def start_server(port=8000, app_files_dir=None, activity=None):
_ensure_android_reticulum_config(reticulum_config_dir)
_clear_stale_storage_lock(storage_dir)
+ try:
+ port_outcome = _wait_for_own_backend_or_free_port(port)
+ except Exception as port_check_exc:
+ port_outcome = "free"
+ print(
+ f"meshchat_wrapper: port availability check skipped: {port_check_exc}"
+ )
+
+ if port_outcome == "serving":
+ print(
+ f"meshchat_wrapper: MeshChatX backend already serving on port {port}, "
+ "skipping duplicate start_server (activity was likely recreated)",
+ )
+ return
+ if port_outcome == "busy":
+ print(
+ f"meshchat_wrapper: port {port} is still occupied by another process "
+ "after waiting, attempting to start anyway",
+ )
+
original_signal = signal.signal
def _safe_signal(sig, handler):

diff --git a/meshchatx.rsm b/meshchatx.rsm
index 6d722303..48055124 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/src/backend/page_node_manager.py b/meshchatx/src/backend/page_node_manager.py
index bddd894e..d01c6d14 100644
--- a/meshchatx/src/backend/page_node_manager.py
+++ b/meshchatx/src/backend/page_node_manager.py
@@ -10,7 +10,10 @@ storage/page_nodes/<node_id>/.
import os
import uuid
-from meshchatx.src.backend.page_node import PageNode, normalize_announce_interval_seconds
+from meshchatx.src.backend.page_node import (
+ PageNode,
+ normalize_announce_interval_seconds,
+)
class PageNodeManager:

diff --git a/meshchatx/src/backend/rns_startup_recovery.py b/meshchatx/src/backend/rns_startup_recovery.py
index c835af35..1d8ffcae 100644
--- a/meshchatx/src/backend/rns_startup_recovery.py
+++ b/meshchatx/src/backend/rns_startup_recovery.py
@@ -29,9 +29,7 @@ _ORIGINAL_PANIC = None
_ORIGINAL_EXIT = None
_EXIT_IN_PROGRESS = False
-# Prefer disabling these types first when init fails without a named culprit.
_HIGH_RISK_TYPES = (
- "I2PInterface",
"RNodeMultiInterface",
"RNodeInterface",
"RNodeIPInterface",
@@ -40,8 +38,46 @@ _HIGH_RISK_TYPES = (
"KISSInterface",
"AX25KISSInterface",
"PipeInterface",
+ "I2PInterface",
)
+_CAPTURED_ERROR_LOG_LINES: list[str] = []
+_ORIGINAL_RNS_LOG = None
+
+
+def _capturing_log(msg, level=3, *args, **kwargs):
+ try:
+ import RNS
+
+ if level <= RNS.LOG_ERROR:
+ _CAPTURED_ERROR_LOG_LINES.append(str(msg))
+ del _CAPTURED_ERROR_LOG_LINES[:-20]
+ except Exception:
+ pass
+ return _ORIGINAL_RNS_LOG(msg, level, *args, **kwargs)
+
+
+@contextlib.contextmanager
+def _capture_rns_error_logs():
+ global _ORIGINAL_RNS_LOG
+ try:
+ import RNS
+ except Exception:
+ yield
+ return
+
+ _CAPTURED_ERROR_LOG_LINES.clear()
+ _ORIGINAL_RNS_LOG = RNS.log
+ RNS.log = _capturing_log
+ try:
+ yield
+ finally:
+ RNS.log = _ORIGINAL_RNS_LOG
+
+
+def _mentions_i2p(text: str) -> bool:
+ return "i2p" in str(text).lower()
+
class RnsPanicError(RuntimeError):
"""Raised instead of os._exit when RNS would panic or hard-exit."""
@@ -75,6 +111,8 @@ def install_rns_panic_containment(*, force: bool = False) -> bool:
if _args:
message = f"RNS.panic(): {_args[0]}"
# Avoid logging handlers here. Panic can run under signal context.
+ if _CAPTURED_ERROR_LOG_LINES:
+ message = message + " | " + " | ".join(_CAPTURED_ERROR_LOG_LINES[-5:])
raise RnsPanicError(message)
def _contained_exit(code: int = 0):
@@ -289,7 +327,7 @@ def apply_startup_recovery_step(
"""Disable something that might be blocking RNS init. Returns disabled names.
Steps escalate with *attempt*:
- 0. Named interfaces from the error (if any), else I2P
+ 0. Named interfaces from the error (if any), else I2P when error mentions I2P
1. RNode / serial / kiss family
2. AutoInterface
3. Any remaining enabled high-risk interface (one at a time)
@@ -308,7 +346,7 @@ def apply_startup_recovery_step(
return disabled
if attempt <= 0:
- if i2p_support.disable_all_i2p_in_config(config_path):
+ if _mentions_i2p(error) and i2p_support.disable_all_i2p_in_config(config_path):
# Names unknown here, so report a synthetic marker for logs/tests.
disabled.append("__i2p__")
return disabled
@@ -354,7 +392,8 @@ def create_reticulum_with_recovery(
last_exc: Exception | None = None
for attempt in range(max_attempts):
try:
- return construct()
+ with _capture_rns_error_logs():
+ return construct()
except Exception as exc:
last_exc = exc
disabled = apply_startup_recovery_step(

diff --git a/tests/backend/test_rns_startup_recovery.py b/tests/backend/test_rns_startup_recovery.py
index e2b74821..68c06534 100644
--- a/tests/backend/test_rns_startup_recovery.py
+++ b/tests/backend/test_rns_startup_recovery.py
@@ -150,6 +150,84 @@ peers = aaa.b32.i2p
)
+def test_apply_startup_recovery_step_does_not_blindly_disable_i2p(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[reticulum]
+enable_transport = True
+[interfaces]
+[[MyI2P]]
+type = I2PInterface
+interface_enabled = true
+peers = aaa.b32.i2p
+""",
+ encoding="utf-8",
+ )
+ disabled = recovery.apply_startup_recovery_step(
+ str(config_path),
+ "some unrelated bind failure with no interface name",
+ attempt=0,
+ )
+ assert disabled == []
+ cfg = ConfigObj(str(config_path))
+ assert str(cfg["interfaces"]["MyI2P"]["interface_enabled"]).lower() in (
+ "true",
+ "yes",
+ "1",
+ )
+
+
+def test_create_reticulum_with_recovery_uses_rns_log_for_unnamed_panic(tmp_path):
+ import RNS
+
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[reticulum]
+enable_transport = True
+[interfaces]
+[[MyI2P]]
+type = I2PInterface
+interface_enabled = true
+peers = aaa.b32.i2p
+[[FlakyTcp]]
+type = TCPClientInterface
+interface_enabled = true
+""",
+ encoding="utf-8",
+ )
+ recovery.install_rns_panic_containment(force=True)
+ calls = {"n": 0}
+
+ def construct():
+ calls["n"] += 1
+ if calls["n"] == 1:
+ RNS.log(
+ 'The interface "FlakyTcp" could not be created. Check your '
+ "configuration file for errors!",
+ RNS.LOG_ERROR,
+ )
+ RNS.panic()
+ return "ok"
+
+ result = recovery.create_reticulum_with_recovery(
+ str(tmp_path),
+ construct=construct,
+ max_attempts=3,
+ )
+ assert result == "ok"
+ cfg = ConfigObj(str(config_path))
+ assert str(cfg["interfaces"]["FlakyTcp"]["interface_enabled"]).lower() in (
+ "false",
+ "no",
+ "0",
+ )
+ assert str(cfg["interfaces"]["MyI2P"]["interface_enabled"]).lower() in (
+ "true",
+ "yes",
+ "1",
+ )
+
+
def test_extract_interface_names_from_error():
names = recovery.extract_interface_names_from_error(
'AutoInterface[HomeLAN] failed; also interface "Radio1" offline',

diff --git a/tests/test_android_meshchat_wrapper.py b/tests/test_android_meshchat_wrapper.py
index c87a78d5..f685dd8d 100644
--- a/tests/test_android_meshchat_wrapper.py
+++ b/tests/test_android_meshchat_wrapper.py
@@ -4,9 +4,13 @@
from __future__ import annotations
+import http.server
import importlib
+import json
+import socket
import sys
import threading
+import time
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -15,6 +19,40 @@ if str(_ANDROID_PY) not in sys.path:
sys.path.insert(0, str(_ANDROID_PY))
+def _free_port():
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.bind(("127.0.0.1", 0))
+ port = sock.getsockname()[1]
+ sock.close()
+ return port
+
+
+class _FakeStatusHandler(http.server.BaseHTTPRequestHandler):
+ def do_GET(self):
+ if self.path != "/api/v1/status":
+ self.send_response(404)
+ self.end_headers()
+ return
+ body = json.dumps(
+ {
+ "status": "ok",
+ "stage": "ready",
+ "network_ready": True,
+ "network_degraded": False,
+ "listen_port": self.server.server_port,
+ "https_enabled": True,
+ },
+ ).encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, *_args, **_kwargs):
+ pass
+
+
def test_start_server_second_call_skips_while_main_blocks(monkeypatch):
import meshchatx.meshchat as mm
@@ -114,3 +152,80 @@ def test_start_server_systemexit_includes_cause(monkeypatch):
message = str(excinfo.value)
assert "code=1" in message
assert "storage lock held by pid 12345" in message
+
+
+def test_wait_for_own_backend_or_free_port_reports_free_port():
+ import meshchat_wrapper
+
+ importlib.reload(meshchat_wrapper)
+ port = _free_port()
+ outcome = meshchat_wrapper._wait_for_own_backend_or_free_port(
+ port,
+ wait_seconds=1.0,
+ poll_interval=0.1,
+ )
+ assert outcome == "free"
+
+
+def test_wait_for_own_backend_or_free_port_detects_existing_backend():
+ import meshchat_wrapper
+
+ importlib.reload(meshchat_wrapper)
+ port = _free_port()
+ server = http.server.HTTPServer(("127.0.0.1", port), _FakeStatusHandler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ started = time.monotonic()
+ outcome = meshchat_wrapper._wait_for_own_backend_or_free_port(
+ port,
+ wait_seconds=5.0,
+ poll_interval=0.1,
+ probe_timeout=1.0,
+ )
+ elapsed = time.monotonic() - started
+ assert outcome == "serving"
+ assert elapsed < 4.0
+ finally:
+ server.shutdown()
+ thread.join(timeout=5.0)
+
+
+def test_wait_for_own_backend_or_free_port_reports_busy_for_foreign_listener():
+ import meshchat_wrapper
+
+ importlib.reload(meshchat_wrapper)
+ port = _free_port()
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ listener.bind(("127.0.0.1", port))
+ listener.listen(1)
+ try:
+ outcome = meshchat_wrapper._wait_for_own_backend_or_free_port(
+ port,
+ wait_seconds=0.2,
+ poll_interval=0.1,
+ probe_timeout=0.3,
+ )
+ assert outcome == "busy"
+ finally:
+ listener.close()
+
+
+def test_start_server_skips_duplicate_when_backend_already_serving(monkeypatch):
+ import meshchatx.meshchat as mm
+
+ calls: list[int] = []
+ monkeypatch.setattr(mm, "main", lambda: calls.append(1))
+
+ import meshchat_wrapper
+
+ importlib.reload(meshchat_wrapper)
+ monkeypatch.setattr(
+ meshchat_wrapper,
+ "_wait_for_own_backend_or_free_port",
+ lambda *_a, **_k: "serving",
+ )
+
+ meshchat_wrapper.start_server(8000, None)
+ assert calls == []
+ assert meshchat_wrapper._server_loop_active is False


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────